/*           Copyright(c) 2005-2008 Dave Sexton            *
 *                   HtmlEditor Control                    *
 * http://www.codeplex.com/DocProject/Project/License.aspx *
 ***********************************************************/
using System;
using System.Collections.Generic;
using System.Text;
using mshtml;
using System.Windows.Forms;
using DaveSexton.Controls.Resources;

namespace DaveSexton.Controls
{
	public class HtmlSelection
	{
		#region Public Properties
		public object Source
		{
			get
			{
				return (textRange != null)
					? (object) textRange
					: (controlRange != null)
						? (object) controlRange
						: (object) document;
			}
		}

		public HtmlDocument SourceDocument
		{
			get
			{
				return editor.Document;
			}
		}

		public object SourceDomDocument
		{
			get
			{
				return document;
			}
		}

		public HtmlSelectionType SelectionType
		{
			get
			{
				if (textRange != null)
					return HtmlSelectionType.Text;
				else if (controlRange != null)
					return HtmlSelectionType.Control;
				else
				// document will not be null
				{
					if (string.Equals(document.selection.type, "control", StringComparison.OrdinalIgnoreCase))
						return HtmlSelectionType.Control;
					else if (string.Equals(document.selection.type, "text", StringComparison.OrdinalIgnoreCase))
						return HtmlSelectionType.Text;
					else
						return HtmlSelectionType.None;
				}
			}
		}

		public bool IsEditable
		{
			get
			{
				bool? editable = null;
				bool foundElement = true;

				if (textRange != null)
					editable = IsEditableRecursive(textRange.parentElement());
				else if (controlRange != null)
					editable = IsEditableRecursive(controlRange.commonParentElement());
				else if (string.Equals(document.selection.type, "control", StringComparison.OrdinalIgnoreCase))
				{
					IHTMLControlRange controls = document.selection.createRange() as IHTMLControlRange;
					IHTMLElement parentElement;

					if (controls == null)
					// In testing, when an object was deleted using the context menu, the selection changed event would be raised and at
					// some point this property was read by the HtmlEditorToolStrip event handler.  The document.selection.type property
					// returned "control" but createRange() did not return an IHTMLControlRange since the object was already deleted.
					// In this case a text range can be created to verify whether the content is still editable.
					{
						IHTMLTxtRangeCollection textRanges = (IHTMLTxtRangeCollection) ((IHTMLSelectionObject2) document.selection).createRangeCollection();

						parentElement = FindCommonEditableParentElement(textRanges);
					}
					else
						parentElement = controls.commonParentElement();

					editable = IsEditableRecursive(parentElement);
				}
				else if (string.Equals(document.selection.type, "text", StringComparison.OrdinalIgnoreCase))
				{
					IHTMLTxtRangeCollection textRanges = (IHTMLTxtRangeCollection) ((IHTMLSelectionObject2) document.selection).createRangeCollection();

					IHTMLElement parentElement = FindCommonEditableParentElement(textRanges);

					editable = IsEditableRecursive(parentElement);
				}
				else if (document.activeElement != null)
					editable = IsEditableRecursive(document.activeElement);
				else
					foundElement = false;

				// When HtmlSelection is used by the HtmlEditor control, assume all content is editable by default,
				// unless no element was found to represent the current selection.
				return (editable.HasValue) ? editable.Value : foundElement;
			}
		}

		public HtmlElement ParentElement
		{
			get
			{
				IHTMLElement element = (IHTMLElement) ParentDomElement;

				if (element == null)
					return null;
				else
					return HtmlEditor.FindElement(editor.Document, element);
			}
		}

		public object ParentDomElement
		{
			get
			{
				if (textRange != null)
					return textRange.parentElement();
				else if (controlRange != null)
					return controlRange.commonParentElement();
				else if (string.Equals(document.selection.type, "control", StringComparison.OrdinalIgnoreCase))
				{
					IHTMLControlRange controls = (IHTMLControlRange) document.selection.createRange();

					return controls.commonParentElement();
				}
				else if (string.Equals(document.selection.type, "text", StringComparison.OrdinalIgnoreCase))
				{
					// find common parent element
					IHTMLTxtRangeCollection textRanges = (IHTMLTxtRangeCollection) ((IHTMLSelectionObject2) document.selection).createRangeCollection();

					IHTMLElement parentElement = null;

					foreach (IHTMLTxtRange range in textRanges)
					{
						IHTMLElement rangeParent = range.parentElement();

						if (parentElement == null)
							parentElement = rangeParent;
						else if (parentElement != rangeParent && !parentElement.contains(rangeParent))
						{
							while (rangeParent != null)
							{
								rangeParent = rangeParent.parentElement;

								if (parentElement == rangeParent || parentElement.contains(rangeParent))
									break;
								else if (rangeParent.contains(parentElement))
								{
									parentElement = rangeParent;
									break;
								}
							}

							if (rangeParent == null)
								throw new InvalidOperationException(Errors.HtmlSelectionCannotFindParentElement);
						}
					}

					return parentElement;
				}

				return null;
			}
		}

		public IEnumerable<HtmlElement> SelectedElements
		{
			get
			{
				foreach (IHTMLElement element in SelectedElementsInternal)
					yield return HtmlEditor.FindElement(editor.Document, element);
			}
		}

		public ICollection<object> SelectedDomElements
		{
			get
			{
				List<object> list = new List<object>();

				foreach (IHTMLElement element in SelectedElementsInternal)
					list.Add(element);

				return new System.Collections.ObjectModel.ReadOnlyCollection<object>(list);
			}
		}
		#endregion

		#region Private / Protected
		internal IEnumerable<IHTMLElement> SelectedElementsInternal
		{
			get
			{
				if (textRange != null)
					yield return GetElementFromTextRange(textRange);
				else if (controlRange != null)
					foreach (IHTMLElement element in GetElementsFromControlRange(controlRange))
						yield return element;
				else if (string.Equals(document.selection.type, "control", StringComparison.OrdinalIgnoreCase))
				{
					IHTMLControlRange controls = (IHTMLControlRange) document.selection.createRange();

					foreach (IHTMLElement element in GetElementsFromControlRange(controls))
						yield return element;
				}
				else if (string.Equals(document.selection.type, "text", StringComparison.OrdinalIgnoreCase))
				{
					IHTMLTxtRangeCollection textRanges = (IHTMLTxtRangeCollection) ((IHTMLSelectionObject2) document.selection).createRangeCollection();

					foreach (IHTMLTxtRange range in textRanges)
						yield return GetElementFromTextRange(range);
				}
			}
		}

		private readonly HtmlEditor editor;
		private readonly IHTMLDocument2 document;
		private readonly IHTMLTxtRange textRange;
		private readonly IHTMLControlRange controlRange;
		#endregion

		#region Constructors
		internal HtmlSelection(HtmlEditor editor, bool createRange)
		{
			if (editor == null)
				throw new ArgumentNullException("editor");

			this.editor = editor;
			document = editor.domDocument;

			if (createRange)
			{
				object range = document.selection.createRange();

				textRange = range as IHTMLTxtRange;
				
				if (textRange == null)
					controlRange = range as IHTMLControlRange;
			}
		}

		internal HtmlSelection(HtmlEditor editor, IHTMLTxtRange textRange)
			: this(editor, false)
		{
			if (textRange == null)
				throw new ArgumentNullException("textRange");

			if (document != textRange.parentElement().document)
				throw new ArgumentException(Errors.EditorDocumentIsDifferentThanArgument, "textRange");

			this.textRange = textRange;
		}

		internal HtmlSelection(HtmlEditor editor, IHTMLControlRange controlRange)
			: this(editor, false)
		{
			if (controlRange == null)
				throw new ArgumentNullException("controlRange");

			if (document != controlRange.commonParentElement().document)
				throw new ArgumentException(Errors.EditorDocumentIsDifferentThanArgument, "controlRange");

			this.controlRange = controlRange;
		}
		#endregion

		#region Methods
		public static bool? IsEditableElement(System.Windows.Forms.HtmlElement element)
		{
			if (element == null)
				throw new ArgumentNullException("element");

			return IsEditableRecursive((IHTMLElement) element.DomElement);
		}

		public static bool? IsEditableElement(object element)
		{
			if (element == null)
				throw new ArgumentNullException("element");

			return IsEditableRecursive((IHTMLElement) element);
		}

		private static bool? IsEditableElement(IHTMLElement3 element)
		{
			string value = element.contentEditable;

			if (!string.IsNullOrEmpty(value))
			{
				if (string.Equals(value, "false", StringComparison.OrdinalIgnoreCase))
					return false;
				else if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase))
					return true;
			}

			return null;
		}

		internal static bool? IsEditableRecursive(IHTMLElement parent)
		{
			if (parent == null)
				return false;

			do
			{
				bool? editable = IsEditableElement((IHTMLElement3) parent);

				if (editable.HasValue)
					return editable.Value;

				parent = parent.parentElement;
			}
			while (parent != null);

			return null;
		}

		public static HtmlSelection Create(HtmlEditor editor, object anyRangeOrDocument)
		{
			IHTMLTxtRange text;
			IHTMLControlRange control;

			if (anyRangeOrDocument is IHTMLDocument)
				return new HtmlSelection(editor, true);
			else if ((text = anyRangeOrDocument as IHTMLTxtRange) != null)
				return new HtmlSelection(editor, text);
			else if ((control = anyRangeOrDocument as IHTMLControlRange) != null)
				return new HtmlSelection(editor, control);
			else
				throw new ArgumentException(Errors.HtmlSelectionInvalidRangeOrDocument, "anyRangeOrDocument");
		}

		private static IHTMLElement FindCommonEditableParentElement(IHTMLTxtRangeCollection textRanges)
		{
			IHTMLElement parentElement = null;

			foreach (IHTMLTxtRange range in textRanges)
			{
				IHTMLElement rangeParent = range.parentElement();

				bool? editable = IsEditableElement((IHTMLElement3) rangeParent);

				if (editable.HasValue && !editable.Value)
					// if any ranges are marked with contentEditable="false" then the entire selection is not editable
					return null;

				if (parentElement == null)
					parentElement = rangeParent;
				else if (parentElement != rangeParent && !parentElement.contains(rangeParent))
				{
					while (rangeParent != null)
					{
						rangeParent = rangeParent.parentElement;

						if (parentElement == rangeParent || parentElement.contains(rangeParent))
							break;
						else if (rangeParent.contains(parentElement))
						{
							parentElement = rangeParent;
							break;
						}
					}

					if (rangeParent == null)
						throw new InvalidOperationException(Errors.HtmlSelectionCannotFindParentElement);
				}
			}

			return parentElement;
		}

		public virtual HtmlCommand CreateCommand(HtmlCommandIdentifier command)
		{
			if (textRange != null)
				return new HtmlCommand(command, editor, this, textRange);
			else if (controlRange != null)
				return new HtmlCommand(command, editor, this, controlRange);
			else if (document != null)
				return new HtmlCommand(command, editor, this, document);

			// this exception will never be thrown due to the immutability of this class
			throw new InvalidOperationException(Errors.HtmlSelectionNotInitialized);
		}

		public virtual void Replace(string replacementHtml)
		{
			if (textRange != null)
				textRange.pasteHTML(replacementHtml);
			else if (controlRange != null)
			{
				if (controlRange.length == 1)
					controlRange.item(0).outerHTML = replacementHtml;
				else
					controlRange.commonParentElement().outerHTML = replacementHtml;
			}
			else if (string.Equals(document.selection.type, "control", StringComparison.OrdinalIgnoreCase))
			{
				IHTMLControlRange controls = (IHTMLControlRange) document.selection.createRange();

				if (controls.length == 1)
					controls.item(0).outerHTML = replacementHtml;
				else
					controls.commonParentElement().outerHTML = replacementHtml;
			}
			else if (string.Equals(document.selection.type, "text", StringComparison.OrdinalIgnoreCase))
			{
				IHTMLTxtRange range = (IHTMLTxtRange) document.selection.createRange();
				range.pasteHTML(replacementHtml);
			}
		}

		public virtual void Replace(object replacementElement)
		{
			if (replacementElement == null)
				throw new ArgumentNullException("replacementElement");

			Replace((IHTMLDOMNode) replacementElement);
		}

		public void Replace(HtmlElement replacement)
		{
			if (replacement == null)
				throw new ArgumentNullException("replacement");

			Replace(replacement.DomElement);
		}

		private void Replace(IHTMLDOMNode replacement)
		{
			GetElementForReplacement().replaceNode(replacement);
		}

		private IHTMLDOMNode GetElementForReplacement()
		{
			if (textRange != null)
				return (IHTMLDOMNode) GetElementFromTextRange(textRange);
			else if (controlRange != null)
				return (IHTMLDOMNode) ((controlRange.length == 1)
					? controlRange.item(0)
					: controlRange.commonParentElement());
			else
			// document will not be null
			{
				if (string.Equals(document.selection.type, "control", StringComparison.OrdinalIgnoreCase))
				{
					IHTMLControlRange controls = (IHTMLControlRange) document.selection.createRange();

					return (IHTMLDOMNode) ((controls.length == 1)
						? controls.item(0)
						: controls.commonParentElement());
				}
				else if (string.Equals(document.selection.type, "text", StringComparison.OrdinalIgnoreCase))
				{
					IHTMLTxtRangeCollection textRanges = (IHTMLTxtRangeCollection) ((IHTMLSelectionObject2) document.selection).createRangeCollection();

					IHTMLElement parentElement = null;

					foreach (IHTMLTxtRange range in textRanges)
					{
						IHTMLElement rangeParent = range.parentElement();

						if (parentElement == null || (parentElement != rangeParent && !parentElement.contains(rangeParent)))
						{
							// find common parent

							while (rangeParent != null)
							{
								rangeParent = rangeParent.parentElement;

								if (parentElement == rangeParent || parentElement.contains(rangeParent))
									break;
								else if (rangeParent.contains(parentElement))
								{
									parentElement = rangeParent;
									break;
								}
							}

							if (rangeParent == null)
								throw new InvalidOperationException(Errors.HtmlSelectionCannotFindParentElement);
						}
					}

					return (IHTMLDOMNode) parentElement;
				}
			}

			throw new InvalidOperationException(Errors.HtmlSelectionCannotFindParentElement);
		}

		private IEnumerable<IHTMLElement> GetElementsFromControlRange(IHTMLControlRange controls)
		{
			for (int i = 0; i < controls.length; i++)
				yield return controls.item(i);
		}

		private IHTMLElement GetElementFromTextRange(IHTMLTxtRange range)
		{
			// text ranges are always returned as anchors with specialAnchor set to true
			// if the parentElement of the current range is not a specialAnchor then one will be created
			IHTMLElement anchor = range.parentElement();
			
			object specialAnchor = anchor.getAttribute("specialAnchor", 1);

			if (specialAnchor == null
				|| specialAnchor == DBNull.Value		// Yep, in testing specialAnchor was DBNull on the <body> element!
				|| !string.Equals(specialAnchor as string, "True", StringComparison.OrdinalIgnoreCase)
				|| anchor.innerText != range.text)
			// create an anchor to contain the text range
			{
				string id = ((IHTMLDocument3) document).uniqueID;	// uniqueID regenerates every time it's accessed

				// uniqueID doesn't ensure that the generated id is unique by "name" also, so it must be modified
				Guid guid = Guid.NewGuid();
				id += guid.ToString().Replace('-', '_');

				range.execCommand("CreateBookmark", false, id);

				anchor = (IHTMLElement) document.anchors.item(id, 0);

				if (string.IsNullOrEmpty(anchor.innerText))
					anchor.innerHTML = "&nbsp;";

				range.moveToElementText(anchor);

				anchor.setAttribute("specialAnchor", true, 1);		// 1 = case-sensitive
				anchor.style.textAlign = "right";
			}

			return anchor;
		}

		public HtmlElement FindElement(Predicate<HtmlElement> predicate)
		{
			foreach (HtmlElement element in SelectedElements)
				if (predicate(element))
					return element;

			return null;
		}

		public object FindDomElement(Predicate<object> predicate)
		{
			foreach (IHTMLElement element in SelectedElementsInternal)
				if (predicate(element))
					return element;

			return null;
		}

		public bool CollapseToStart()
		{
			if (textRange != null)
			{
				textRange.collapse(true);
				return true;
			}

			return false;
		}

		public bool CollapseToEnd()
		{
			if (textRange != null)
			{
				textRange.collapse(false);
				return true;
			}

			return false;
		}
		#endregion
	}
}
